Repository navigation
feat(cdp): accept an idempotency key on the transactional email route - #120510
trunk-io[bot] merged 1 commit into
Conversation
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.
|
| First copy | Second copy | Lines | Tokens |
|---|---|---|---|
products/batch_exports/backend/tests/temporal/destinations/s3/test_activity.py:111 |
products/batch_exports/backend/tests/temporal/destinations/s3/test_activity.py:264 |
70 | 339 |
products/tasks/backend/logic/services/agent_server_launcher.py:677 |
products/tasks/backend/logic/services/sandbox.py:660 |
40 | 278 |
products/tasks/backend/logic/services/agent_server_launcher.py:302 |
products/tasks/backend/logic/services/docker_sandbox.py:950 |
38 | 246 |
products/batch_exports/backend/tests/temporal/destinations/azure_blob/utils.py:246 |
products/batch_exports/backend/tests/temporal/destinations/s3/utils.py:70 |
34 | 221 |
products/batch_exports/backend/tests/temporal/destinations/azure_blob/utils.py:246 |
products/batch_exports/backend/tests/temporal/destinations/base_destination_tests.py:384 |
34 | 212 |
products/warehouse_sources/backend/temporal/data_imports/sources/courier/courier.py:9 |
products/warehouse_sources/backend/temporal/data_imports/sources/solarwinds_service_desk/solarwinds_service_desk.py:6 |
27 | 204 |
products/warehouse_sources/backend/tests/api/test_external_data_source.py:6032 |
products/warehouse_sources/backend/tests/api/test_external_data_source.py:6111 |
49 | 196 |
products/batch_exports/backend/tests/temporal/destinations/postgres/utils.py:116 |
products/batch_exports/backend/tests/temporal/destinations/redshift/utils.py:142 |
40 | 195 |
products/batch_exports/backend/tests/temporal/destinations/bigquery/utils.py:133 |
products/batch_exports/backend/tests/temporal/destinations/workflows/utils.py:52 |
39 | 187 |
products/warehouse_sources/backend/temporal/data_imports/sources/trustpilot/trustpilot.py:5 |
products/warehouse_sources/backend/temporal/data_imports/sources/veeqo/veeqo.py:31 |
31 | 187 |
products/batch_exports/backend/tests/temporal/destinations/s3/test_activity.py:131 |
products/batch_exports/backend/tests/temporal/destinations/s3/test_activity_with_gcs_bucket.py:105 |
33 | 179 |
products/warehouse_sources/backend/temporal/data_imports/sources/amazon_s3/source.py:1 |
products/warehouse_sources/backend/temporal/data_imports/sources/google_cloud_storage/source.py:1 |
30 | 177 |
products/warehouse_sources/backend/temporal/data_imports/sources/companycam/companycam.py:6 |
products/warehouse_sources/backend/temporal/data_imports/sources/confluence/confluence.py:6 |
23 | 163 |
products/batch_exports/backend/tests/temporal/destinations/bigquery/test_workflow.py:128 |
products/batch_exports/backend/tests/temporal/destinations/bigquery/test_workflow.py:217 |
33 | 160 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_compute_optimizer/source.py:21 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_step_functions/source.py:18 |
16 | 141 |
products/warehouse_sources/backend/temporal/data_imports/sources/abnormal_security/source.py:25 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_config/source.py:25 |
14 | 137 |
products/warehouse_sources/backend/temporal/data_imports/sources/openalex/openalex.py:5 |
products/warehouse_sources/backend/temporal/data_imports/sources/sprig/sprig.py:6 |
16 | 135 |
products/warehouse_sources/backend/temporal/data_imports/sources/thinkific/source.py:60 |
products/warehouse_sources/backend/temporal/data_imports/sources/thinkific_courses/source.py:61 |
35 | 132 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_systems_manager/source.py:127 |
35 | 131 |
products/warehouse_sources/backend/temporal/data_imports/sources/algolia/algolia.py:20 |
products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/qualaroo.py:7 |
18 | 127 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_batch/aws_batch.py:21 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/aws_sagemaker.py:20 |
14 | 127 |
products/warehouse_sources/backend/temporal/data_imports/sources/lokalise/lokalise.py:1 |
products/warehouse_sources/backend/temporal/data_imports/sources/ticketmaster/ticketmaster.py:1 |
18 | 126 |
products/warehouse_sources/backend/temporal/data_imports/sources/adobe_analytics/source.py:26 |
products/warehouse_sources/backend/temporal/data_imports/sources/shopify/source.py:17 |
15 | 117 |
products/warehouse_sources/backend/temporal/data_imports/sources/ashby/ashby.py:12 |
products/warehouse_sources/backend/temporal/data_imports/sources/openalex/openalex.py:7 |
23 | 117 |
products/warehouse_sources/backend/temporal/data_imports/sources/better_stack/better_stack.py:22 |
products/warehouse_sources/backend/temporal/data_imports/sources/xmatters/xmatters.py:19 |
16 | 114 |
products/warehouse_sources/backend/temporal/data_imports/sources/confluence/confluence.py:6 |
products/warehouse_sources/backend/temporal/data_imports/sources/trustpilot/trustpilot.py:7 |
14 | 111 |
products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/qualaroo.py:163 |
products/warehouse_sources/backend/temporal/data_imports/sources/rentcast/rentcast.py:143 |
27 | 111 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_batch/aws_batch.py:21 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_step_functions/aws_step_functions.py:23 |
12 | 109 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_step_functions/source.py:123 |
29 | 107 |
products/warehouse_sources/backend/temporal/data_imports/sources/bitrise/bitrise.py:24 |
products/warehouse_sources/backend/temporal/data_imports/sources/pagerduty/pagerduty.py:22 |
21 | 107 |
products/warehouse_sources/backend/temporal/data_imports/sources/companycam/companycam.py:6 |
products/warehouse_sources/backend/temporal/data_imports/sources/lattice/lattice.py:6 |
16 | 107 |
products/warehouse_sources/backend/temporal/data_imports/sources/infisical/infisical.py:12 |
products/warehouse_sources/backend/temporal/data_imports/sources/sourcegraph/sourcegraph.py:9 |
12 | 107 |
products/warehouse_sources/backend/temporal/data_imports/sources/azure_cost_management/source.py:82 |
products/warehouse_sources/backend/temporal/data_imports/sources/sharepoint/source.py:221 |
30 | 105 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_security_hub/source.py:142 |
28 | 103 |
products/warehouse_sources/backend/temporal/data_imports/sources/amazon_s3/source.py:11 |
products/warehouse_sources/backend/temporal/data_imports/sources/cloudflare_r2/source.py:12 |
20 | 102 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_sagemaker/source.py:152 |
products/warehouse_sources/backend/temporal/data_imports/sources/aws_savings_plans/source.py:133 |
27 | 101 |
products/warehouse_sources/backend/temporal/data_imports/sources/rippling/rippling.py:200 |
products/warehouse_sources/backend/temporal/data_imports/sources/simplesat/simplesat.py:117 |
22 | 101 |
products/warehouse_sources/backend/temporal/data_imports/sources/koyeb/koyeb.py:1 |
products/warehouse_sources/backend/temporal/data_imports/sources/simplesat/simplesat.py:1 |
13 | 99 |
posthog/api/project.py:737 |
posthog/api/project.py:849 |
49 | 98 |
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 |
products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/source.py:64 |
27 | 98 |
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 |
products/warehouse_sources/backend/temporal/data_imports/sources/sendowl/source.py:64 |
27 | 98 |
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 |
products/warehouse_sources/backend/temporal/data_imports/sources/shopwired/source.py:61 |
27 | 98 |
products/warehouse_sources/backend/temporal/data_imports/sources/amazon_s3/source.py:13 |
products/warehouse_sources/backend/temporal/data_imports/sources/azure_blob/source.py:12 |
18 | 97 |
products/warehouse_sources/backend/temporal/data_imports/sources/aha/aha.py:27 |
products/warehouse_sources/backend/temporal/data_imports/sources/qualaroo/qualaroo.py:17 |
18 | 94 |
products/access_control/backend/facade/enums.py:2 |
products/tracing/backend/facade/enums.py:2 |
14 | 93 |
products/tasks/backend/temporal/client.py:341 |
products/tasks/backend/temporal/client.py:445 |
17 | 92 |
products/warehouse_sources/backend/temporal/data_imports/sources/motion/motion.py:3 |
products/warehouse_sources/backend/temporal/data_imports/sources/pagerduty/pagerduty.py:11 |
11 | 89 |
products/warehouse_sources/backend/temporal/data_imports/sources/phyllo/phyllo.py:4 |
products/warehouse_sources/backend/temporal/data_imports/sources/picqer/picqer.py:7 |
11 | 89 |
products/tasks/backend/temporal/client.py:269 |
products/tasks/backend/temporal/client.py:366 |
15 | 84 |
products/tasks/backend/logic/services/agent_server_launcher.py:340 |
products/tasks/backend/logic/services/docker_sandbox.py:992 |
20 | 82 |
products/warehouse_sources/backend/temporal/data_imports/sources/culture_amp/source.py:68 |
products/warehouse_sources/backend/temporal/data_imports/sources/personio/source.py:72 |
23 | 82 |
products/warehouse_sources/backend/temporal/data_imports/sources/culture_amp/source.py:68 |
products/warehouse_sources/backend/temporal/data_imports/sources/sage_intacct/source.py:68 |
23 | 81 |
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:138 |
products/warehouse_sources/backend/temporal/data_imports/sources/justcall/source.py:67 |
21 | 77 |
products/warehouse_sources/backend/temporal/data_imports/sources/campaign_monitor/source.py:133 |
products/warehouse_sources/backend/temporal/data_imports/sources/kickscale/source.py:127 |
23 | 76 |
products/warehouse_sources/backend/temporal/data_imports/sources/digitalocean/settings.py:353 |
products/warehouse_sources/backend/temporal/data_imports/sources/digitalocean/settings.py:372 |
14 | 76 |
products/warehouse_sources/backend/temporal/data_imports/sources/kisi/source.py:60 |
products/warehouse_sources/backend/temporal/data_imports/sources/speedcurve/source.py:61 |
21 | 76 |
products/warehouse_sources/backend/temporal/data_imports/sources/appcues/source.py:139 |
products/warehouse_sources/backend/temporal/data_imports/sources/trustpilot/source.py:66 |
22 | 75 |
products/warehouse_sources/backend/temporal/data_imports/sources/decagon/source.py:56 |
products/warehouse_sources/backend/temporal/data_imports/sources/lingo_dev/source.py:61 |
19 | 75 |
products/warehouse_sources/backend/temporal/data_imports/sources/amplemarket/source.py:64 |
products/warehouse_sources/backend/temporal/data_imports/sources/anvil/source.py:67 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/amplemarket/source.py:64 |
products/warehouse_sources/backend/temporal/data_imports/sources/apollo/source.py:71 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/amplemarket/source.py:64 |
products/warehouse_sources/backend/temporal/data_imports/sources/chartmogul/source.py:68 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/cloudflare/source.py:207 |
products/warehouse_sources/backend/temporal/data_imports/sources/coda/source.py:60 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/cloudflare/source.py:207 |
products/warehouse_sources/backend/temporal/data_imports/sources/monday/source.py:84 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/deel/source.py:67 |
products/warehouse_sources/backend/temporal/data_imports/sources/dixa/source.py:69 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/deel/source.py:67 |
products/warehouse_sources/backend/temporal/data_imports/sources/pingdom/source.py:67 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/hubplanner/source.py:59 |
products/warehouse_sources/backend/temporal/data_imports/sources/onfleet/source.py:62 |
19 | 74 |
products/warehouse_sources/backend/temporal/data_imports/sources/less_annoying_crm/source.py:63 |
products/warehouse_sources/backend/temporal/data_imports/sources/metaplane/source.py:58 |
19 | 74 |
products/alerts_platform/backend/activity_logging.py:29 |
products/logs/backend/activity_logging.py:53 |
15 | 73 |
products/warehouse_sources/backend/temporal/data_imports/sources/checkout_com/source.py:250 |
products/warehouse_sources/backend/temporal/data_imports/sources/plaid/source.py:90 |
18 | 72 |
products/warehouse_sources/backend/temporal/data_imports/sources/jotform/source.py:78 |
products/warehouse_sources/backend/temporal/data_imports/sources/knowbe4/source.py:122 |
20 | 72 |
products/warehouse_sources/backend/temporal/data_imports/sources/jotform/source.py:78 |
products/warehouse_sources/backend/temporal/data_imports/sources/komodor/source.py:111 |
20 | 72 |
⚠️ Duplication (TypeScript) — 11 new duplicated blocks (worst 592 tokens)
New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.
| First copy | Second copy | Lines | Tokens |
|---|---|---|---|
frontend/src/lib/utils/liquid.ts:7 |
nodejs/src/cdp/utils/liquid.ts:88 |
100 | 592 |
frontend/src/lib/oauthScopes.generated.ts:12 |
services/mcp/src/lib/oauth-scopes.generated.ts:12 |
253 | 521 |
services/mcp/scripts/generate-tools.ts:2224 |
services/mcp/scripts/generate-tools.ts:2249 |
20 | 194 |
frontend/src/scenes/dashboard/DashboardItems.test.tsx:441 |
frontend/src/scenes/dashboard/DashboardItems.test.tsx:493 |
33 | 161 |
services/mcp/scripts/generate-tools.ts:2202 |
services/mcp/scripts/generate-tools.ts:2230 |
13 | 156 |
services/mcp/scripts/yaml-config-schema.ts:190 |
services/mcp/scripts/yaml-config-schema.ts:611 |
32 | 113 |
services/mcp/scripts/yaml-config-schema.ts:201 |
services/mcp/src/tools/toolDefinitions.ts:23 |
21 | 94 |
frontend/src/lib/utils/eventUsageLogic.ts:1624 |
frontend/src/scenes/onboarding/shared/wizard-sync/installationProgressLogic.ts:175 |
28 | 92 |
frontend/src/products.tsx:2707 |
products/error_tracking/manifest.tsx:108 |
14 | 87 |
products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts:96 |
products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts:63 |
22 | 77 |
frontend/src/scenes/dashboard/dashboardLogic.tsx:4818 |
frontend/src/scenes/dashboard/dashboardLogic.tsx:4835 |
11 | 76 |
⚠️ Comment density — 4% of added code lines are comments (5 of 132)
This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.
Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.
Files with the most added comment lines:
| File | Comment lines | Added lines |
|---|---|---|
nodejs/src/cdp/cdp-api.ts |
3 | 50 |
nodejs/src/cdp/cdp-api.serial.test.ts |
1 | 75 |
nodejs/src/cdp/services/hogflows/transactional-email.ts |
1 | 7 |
This check does not block merging. It updates on every push and clears when the share drops.
PostHog Review · Deep · reviewed
|
| Step | Status | Result |
|---|---|---|
| Prepare the diff | done | 2 files, 1 chunk |
| Pick perspectives | done | 3 perspectives |
| Run review passes | done | 0 issues |
| Merge overlapping findings | done | 0 → 0 |
| Validate findings | done | |
| Publish | done | Nothing to post |
35deda7 to
9c19d1f
Compare
|
9c19d1f to
6e40f5f
Compare
|
Risk: No findings This PR adds optional idempotency-key handling to cdp-api's transactional-email route: the key is reserved in a team-scoped Valkey store before queueing, retries replay the first invocation, and quota-rejected or failed queue writes release the key. I found no security vulnerability: the route's JWT auth is unchanged, keys are team-scoped and hashed, the reserve is atomic, and the release/mark-queued scripts check invocation ownership so late releases cannot free another request's key. The known residual (a possible duplicate after a 60-second crash window) is a documented same-caller correctness trade-off, not a security boundary. Sentinel reviewed |
cdp-api's transactional email route accepts an optional idempotency_key from Django and reserves it in the shared IdempotencyKeys store before it checks the quota and queues the send. A repeat of the same request returns the first invocation id with replayed: true, a key reused for a different request returns 422, a repeat while the first request still queues returns 409, and an unavailable store returns 503. A queue failure or quota rejection frees the key. Django does not send the key yet, so this route behaves as before until the Django change lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Generated-By: PostHog Desktop Task-Id: 37f4fab5-e683-4a35-a865-b07749cbaa38
6e40f5f to
164b489
Compare
New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.
There was a problem hiding this comment.
Approved.
The risky changes have owning-team authorship and STRONG familiarity, with focused tests and no unresolved substantive review concerns. The reported database-pool test failure does not identify a defect introduced by this diff.
- Author wrote 75% of the modified lines and has 27 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 67L, 2F substantive, 163L/3F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1c-medium (163L, 3F, single-area, feat) |
| stamphog 2.6.0 | .stamphog/policy.yml @ 164b489 · reviewed head 164b489 |
|
This pull request was merged into |
Problem
Changes
idempotency_keyfrom Django. It reserves the key in the sharedIdempotencyKeysstore from #120446 before it queues the send.202, the newinvocation_id,replayed: false202, the firstinvocation_id,replayed: true. Nothing queues.422 idempotency_key_reused409 idempotency_key_in_flight503 idempotency_unavailable. Nothing queues.cdp_transactional_email_requests_totalgains thereplayedandidempotency_*outcomes.How did you test this code?
Test rationale: five cases extend the existing
transactional emailblock incdp-api.serial.test.ts, against the real Valkey. A retry returns the first send and queues once. A reused key returns 422. A retry during a held queue write returns 409. A queue failure frees the key for the retry. No store means 503 and nothing queued. No existing test covered a repeated request.The end-to-end demo of the whole API, with this route and the Django half, is on #120392.
Release status
Docs update
None. The endpoint is flagged and has no public docs yet.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code (PostHog Desktop cloud task), Claude Opus 5.5
/writing-tests,/stacking-prs,/writing-pr-descriptions.Created with PostHog Desktop
🤖 Generated with Claude Code